#!/usr/bin/env python
"""
Parse an ebuild's metadata file for useful information on systems using
Gentoo's Portage package manager (or equivalent) and Portage tree.

Author:    Douglas Anderson (djanderson)
Copyright: 2008 Douglas Anderson
License:   Distributed under the terms of the GNU General Public License v3

    emeta is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    emeta is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with emeta.  If not, see <http://www.gnu.org/licenses/>.

TODO
 * Make more use of Gentoo portage module where appropriate
 * Support all `lang' and `restrict' attributes

KNOWN ISSUES
 * None
"""

# Move to Imports section after Python-2.6 is stable
from __future__ import with_statement

__prog__    = "emeta"
__version__ = "2.0.2"
__author__  = "Douglas Anderson (djanderson)"
__license__ = "Distributed under the terms of the GPL v3"
version_string = """\
%(__prog__)s version %(__version__)s
Copyright 2008 %(__author__)s
%(__license__)s""" % locals()

# ==============
# Import modules
# ==============

# Standard library
import os
import re 
import sys
import textwrap
from itertools import izip
# Option handling
from optparse import OptionParser, OptionValueError, SUPPRESS_HELP
from glob import glob
# XML parsing
try:
    import xml.etree.cElementTree as ET
except ImportError:
    import xml.etree.ElementTree as ET

# Gentoo specific
from portage import settings
try:
    # Portage 2.2?
    import portage.output as put
except ImportError:
    # Portage 2.1?
    import output as put

    # Usage conventions for this module
    # ---------------------------------
    #     green: Use for category/package-version strings
    #       red: Use for error messages
    #    yellow: Use for this program's options
    # turquoise: Use for this program's name
    #      blue: Use for useflags
    #      bold: Use for general emphasis

# =====================================
# Define command line interface options
# =====================================

# Check for --no-color before colorizing the usage string
for opt in sys.argv[1:]:
    if re.match("-\w*C|--no-color", opt):
        put.nocolor() 

usage = "%s %s %s" % (put.turquoise(__prog__), put.yellow("[options]"),
                      put.green("[category/]package"))
desc = "Parse an ebuild's metadata file for useful information"
opt_handler = OptionParser(usage=usage, prog=__prog__, version=version_string,
                           description=desc)

# Disallow `emeta -H vim -d emacs' style args because emeta can't return 
# different information for different packages. Inputting the above will 
# return: emeta: error: !!! `-d' is not a valid package name.
opt_handler.disable_interspersed_args()

# --version displays here
# -h, --help displays here
opt_handler.add_option("-d", "--desc", action="store_true",
                       help="show an extended package description")
opt_handler.add_option("-H", "--herd", action="store_true",
                       help="show the herd(s) for the package")
opt_handler.add_option("-m", "--maintainer", action="store_true",
                       help="show the maintainer(s) for the package")
opt_handler.add_option("-u", "--useflags", action="store_true",
                       help="show per-package USE flag descriptions")
opt_handler.add_option("-U", "--upstream", action="store_true",
                       help="show package's upstream information")
opt_handler.add_option("-x", "--xml", action="store_true",
                       help="show the plain XML file")
opt_handler.add_option("-q", "--quiet", action="store_true",
                       help="be less verbose")
opt_handler.add_option("-C", "--no-color", action="store_true",
                       help="turn off color in output")
# You have to be in the directory which contains metadata_complete.xml and use
# a valid pkg name for testing. Ex: `cd /home/user/dev && emeta --test xterm'
opt_handler.add_option("--test", action="store_true", help=SUPPRESS_HELP)

(input_options, input_args) = opt_handler.parse_args()

# =============
# Set constants
# =============

# Set the location of the main Portage tree
portdir = [settings["PORTDIR"] or os.path.join(os.sep, "usr", "portage")]
# Check for overlays
if settings["PORTDIR_OVERLAY"]:
    portdir.extend(settings["PORTDIR_OVERLAY"].split())

# Set terminal size
(term_height, term_width) = put.get_term_size()
if term_width is -1:
    # term_size() failed. Set a sane default width:
    term_width = 80

# Specify padded_width used by fmt_line()
padded_width = term_width - 2

err_flag = put.red("!!!")

# ================
# Define functions
# ================

def find_package_dir(input_args):
    """Return a list containing one valid package directory for each pkg
    in input_args or raise error.

    pkg can be in the form of pkg or cat/pkg. cat/pkg-ver is not supported
    because metadata is version-independent.

    Directories are searched in the order of main tree, then overlays. The
    first match is returned because overlays rarely contain interesting
    metadata.

    @type input_args: list
    @param input_args: list of [cat/]pkg names
    @return: A list of valid pkg directories
    """
    result = []
    pat = re.compile(".+/.+")
    for pkg in reversed(input_args):
        got_match = False
        for dir in portdir:
            match = []
            if pat.match(pkg):
                # Got a category/package name
                match.append(os.path.join(dir, pkg))
            else:
                # Didn't get a category, so search for the correct directory
                g = glob("%s/*/%s" % (dir, pkg))
                if g: match.append(g[0])
            if match and os.path.isdir(match[0]):
                # Got a valid pkg directory
                got_match = True
                result.append(match[0])
        if not got_match:
            # Got no valid pkg directories; raise an error
            opt_error = "is not a valid package name."
            opt_handler.error("%s `%s' %s" % (err_flag, pkg, opt_error))

    return result

def fmt_line(line, fl="", sl="", *args):
    """Wrap a string at word boundaries and optionally indent the first line
    and/or subsequent lines with custom strings.

    Preserve newlines if the longest line is not longer than padded_width. To
    force the preservation of newlines and indents, split() the string into a
    list and feed it to fmt_line() via fmt_list().

    @see: fmt_list()
    @type line: string
    @param line: text to format
    @type fl: string
    @param fl: text to prepend to the first line
    @type sl: string
    @param sl: text to prepend to subsequent lines
    @return: A string
    """
    if line:
        line = line.expandtabs().strip("\n").splitlines() 
    else:
        if "quiet" not in args:
            return fl + "None specified"
        else:
            return

    if len(fl) > len(sl):
        wider_indent = fl
    else:
        wider_indent = sl
    
    widest_line_len = len(max(line, key=len)) + len(wider_indent)
    
    if widest_line_len > padded_width:
        # line won't fit inside padded_width, so wrap it.
        line = " ".join(line)
        line = re.sub("\s+", " ", line)
        line = textwrap.dedent(line).strip()
        result = textwrap.fill(line, width=padded_width, expand_tabs=False,
                               initial_indent=fl, subsequent_indent=sl)
    else:
        # line will fit inside padded_width, so preserve significant \s and \n
        try:
            if line[-1].isspace():
                del line[-1]                # Avoid trailing blank lines
            line[0] = fl + line[0].lstrip() # Avoid two newlines if len == 1
            if len(line) > 1:
                line[0] = line[0] + "\n"
                for e in line[1:-1]:
                    i = line.index(e)
                    line[i] = sl + line[i] + "\n"
                line[-1] = sl + line[-1]    # Avoid two newlines on last line
        except IndexError:
            pass

        result = "".join(line)

    return result.encode("utf-8")

def fmt_list(lst, fl="", sl="", *args):
    """Feed elements of a list to fmt_line().

    @see: fmt_line()
    @type lst: list
    @param lst: list to format
    @type fl: string
    @param fl: text to prepend to the first line
    @type sl: string
    @param sl: text to prepend to subsequent lines
    @return: A list
    """
    result = []
    if lst:
        # Format the first line
        line = fmt_line(lst[0], fl, sl, *args)
        result.append(line)
        # Format subsequent lines
        for e in lst[1:]:
            if e:
                result.append(fmt_line(e, sl, sl, *args))
            else:
                # We don't want to send a blank line to fmt_line()
                result.append("")
    else:
        if not input_options.quiet:
            # Send empty list, we'll get back fl + `None specified'
            result.append(fmt_line(lst, fl, sl, *args))

    return result

def get_herd(xml_tree):
    """Return a list of text nodes for <herd>."""
    return [e.text for e in xml_tree.findall("herd")]

def get_longdesc(xml_tree):
    """Return a list of text nodes for <longdescription>.

    @todo: Support the `lang' attribute
    """
    return [e.text for e in xml_tree.findall("longdescription")]

def get_maintainer(xml_tree):
    """Return a parsable tree of all maintainer elements and sub-elements."""
    first_run = True
    result = []
    for node in xml_tree.findall("maintainer"):
        if not first_run:
            result.append("")
        restrict = node.get("restrict")
        if restrict:
            result.append("({Efor} {G" + restrict + "})")
        result.extend([e.text for e in node])
        first_run = False

    return result

def get_useflags(xml_tree):
    """Return a list of formatted <useflag> lines, including blank elements
    where blank lines should be printed."""
    first_run = True
    result = []
    for node in xml_tree.getiterator("flag"):
        if not first_run:
            result.append("")
        flagline = "{B" + node.get("name") + "}"
        restrict = node.get("restrict")
        if restrict:
            result.append(flagline + " ({Efor} {G" + restrict + "})")
        else:
            result.append(flagline)
        # ElementTree handles nested element text in a funky way. In the node:
        # <flag ...>Installs <pkg>cat/pkg</pkg> for feature x</flag>
        #   >>> print node.text     # returns:
        #   Installs 
        #
        # So we need to dump the raw XML and parse it manually.
        flagxml = ET.tostring(node)
        flagxml = re.sub("\s+", " ", flagxml)
        flagxml = re.sub("\n\t", "", flagxml)
        flagxml = re.sub("<(pkg|cat)>([^<]*)</(pkg|cat)>", r"{G\2}", flagxml)
        flagtext = re.sub("<.*?>", "", flagxml)
        result.append(flagtext)
        first_run = False

    return result

def get_upstream(xml_tree):
    """Return a list of formatted <upstream> lines, including blank elements
    where blank lines should be printed."""
    first_run = True
    result = []
    for node in xml_tree.findall("upstream"):
        if not first_run:
            result.append("")

        # Get upstream maintainer
        maintainer = node.findall("maintainer")
        maint = []
        for e in maintainer:
            name = e.find("name")
            email = e.find("email")
            if e.get("status") == "active":
                status = "({Gactive})"
            elif e.get("status") == "inactive":
                status = "({Rinactive})"
            elif e.get("status"):
                status = "(" + e.get("status") + ")"
            else:
                status = ""
            maint.append(" ".join([name.text, email.text, status]))

        maint = fmt_list(maint, "Maintainer: ", " " * 12, "quiet") 
        if maint[0]:
            result.append("\n".join(maint))

        # Get upstream changelog location
        cl = [e.text for e in node.findall("changelog")]

        changelog = fmt_list(cl, "Changelog:  ", " " * 12, "quiet")
        if changelog[0]:
            result.append("\n".join(changelog))

        # Get upstream documentation string
        d = []
        for e in node.findall("doc"):
            lang = e.get("lang")
            if lang:
                lang = "({Y" + lang + "})"
            else:
                lang = ""
            d.append(" ".join([e.text, lang]))

        doc = fmt_list(d, "Docs:       ", " " * 12, "quiet")
        if doc[0]:
            result.append("\n".join(doc))

        # Get upstream bug tracker location
        bt = [e.text for e in node.findall("bugs-to")]

        bugs_to = fmt_list(bt, "Bugs to:    ", " " * 12, "quiet")
        if bugs_to[0]:
            result.append("\n".join(bugs_to))

        # Get upstream remote-id
        r_id = [e.get("type") + ": " + e.text
                for e in node.findall("remote-id")]

        remote_id = fmt_list(r_id, "Remote ID:  ", " " * 12, "quiet")
        if remote_id[0]:
            result.append("\n".join(remote_id))

        first_run = False

    return result

def print_seq(seq, *args):
    """Print and optionally colorize each element of a sequence."""
    for e in seq:
        if "colorize" in args:
            e = to_ansi_colors(e)
        print e

def to_ansi_colors(s):
    """Translate {Btext} style color markers into ANSI color codes.

    This step needs to happen last, because Python's textwrap module doesn't
    ignore ANSI color codes, so colored text is wrapped prematurely
    (significantly in some cases).

    @return: A colorized string
    """
    # red
    result = re.sub("{\n?R([-=<>./\w\n\s]*)}", put.red(r"\1"), s)
    # green
    result = re.sub("{\n?G([-=<>./\w\n\s]*)}", put.green(r"\1"), result)
    # blue
    result = re.sub("{\n?B([-=<>./\w\n\s]*)}", put.blue(r"\1"), result)
    # yellow
    result = re.sub("{\n?Y([-=<>./\w\n\s]*)}", put.yellow(r"\1"), result)
    # bold/emphasis
    result = re.sub("{\n?E([-=<>./\w\n\s]*)}", put.bold(r"\1"), result)

    return result

def uniqify(seq, trans=None): 
    """Return a uniqified list; preserving order. Optionally, it can take a
    transform function.

    Example usage:
        >>> a = list('ABeeE')
        >>> uniqify(a)
        ['A','B','e','E']
        >>> uniqify(a, lambda x: x.lower())
        ['A','B','e']

    @see: http://www.peterbe.com/plog/uniqifiers-benchmark
    @return: A uniqified list
    """
    if trans is None:
        def trans(x): return x
    seen = {}
    result = []
    for item in seq:
        marker = trans(item)
        if marker in seen: continue
        seen[marker] = 1
        result.append(item)

    return result

def print_file(f):
    """Display the contents of a file."""
    with open(f) as file:
        lines = file.read()
        print lines.strip()

def main():
    """Parse input and run the program."""

    # We need at least one program name to run
    if not input_args:
        opt_error = "You didn't specify a package name."
        opt_handler.error("%s %s" % (err_flag, opt_error))

    # Find program's Portage directory and throw error if invalid
    package_dir = uniqify(find_package_dir(input_args))

    # Use package_dir to get the location of metadata.xml
    metadata_path = [os.path.join(dir, "metadata.xml") for dir in package_dir]

    # For testing
    try:
        if input_options.test:
            metadata_path = ["./metadata_complete.xml"]
    except AttributeError:
        pass

    # --------------------------------
    # Check options and call functions
    # --------------------------------

    first_run = True
    for dir, meta in izip(reversed(package_dir), reversed(metadata_path)):
        if not first_run: print ""

        # Isolate the category/package [overlay] name:
        if not input_options.quiet:
            cat_pkg = '/'.join(dir.split('/')[-2:])
            print put.bold(" *"), put.green(cat_pkg),
            o_dir = '/'.join(dir.split('/')[:-2])
            if o_dir != portdir[0]:
                # o_dir is an overlay
                is_overlay = True
                o_name = o_dir.split('/')[-1]
                o_name = ["[", o_name, "]"]
                print put.turquoise("".join(o_name))
            else:
                print ""

        # Load the metadata file into the XML parser:
        try:
            xml_tree = ET.parse(meta)
        except IOError:
            # There's no metadata file in the directory
            print err_flag, "No metadata available."
            break

        got_opts = False
        # Check options and call functions:
        if (input_options.herd or input_options.desc or
            input_options.maintainer or input_options.useflags or
            input_options.upstream or input_options.xml):
            # Specific information requested
            got_opts = True
            
        # -H, --herd
        if input_options.herd or not got_opts:
            herd = get_herd(xml_tree)
            if input_options.herd:
                herd = fmt_list(herd)
            else:
                herd = fmt_list(herd, "Herd:        ", " " * 13)
            print_seq(herd)

        # -m, --maintainer
        if input_options.maintainer or not got_opts:
            maint = get_maintainer(xml_tree)
            if input_options.maintainer:
                maint = fmt_list(maint)
            else:
                maint = fmt_list(maint, "Maintainer:  ", " " * 13)
            print_seq(maint)

        # -U, --upstream
        if input_options.upstream or not got_opts:
            upstream = get_upstream(xml_tree)
            if input_options.upstream:
                upstream = fmt_list(upstream)
            else:
                upstream = fmt_list(upstream, "Upstream:    ", " " * 13)
            print_seq(upstream, "colorize")

        # -d, --desc
        if input_options.desc or not got_opts:
            desc = get_longdesc(xml_tree)
            if input_options.desc:
                desc = fmt_list(desc)
            else:
                desc = fmt_list(desc, "Description: ", " " * 13)
            print_seq(desc)

        # -u, --useflags
        if input_options.useflags or not got_opts:
            useflags = get_useflags(xml_tree)
            if input_options.useflags:
                useflags = fmt_list(useflags)
            else:
                useflags = fmt_list(useflags, "USE flags:   ", " " * 13)
            print_seq(useflags, "colorize")

        # -x, --xml
        if input_options.xml:
            print_file(meta)

        first_run = False

    # END

# ===================================
# Call function main() to run program
# ===================================

if __name__ == "__main__":
    main()

# vim: tabstop=4 softtabstop=4 expandtab shiftwidth=4
